Skip to content

Bug fixes and SmallFamilyView configurable slot#575

Closed
MtlPhil wants to merge 67 commits intoloopandlearn:live-activityfrom
achkars-org:live-activity
Closed

Bug fixes and SmallFamilyView configurable slot#575
MtlPhil wants to merge 67 commits intoloopandlearn:live-activityfrom
achkars-org:live-activity

Conversation

@MtlPhil
Copy link
Copy Markdown

@MtlPhil MtlPhil commented Mar 25, 2026

Summary

Bug fixes

  • Not Looping overlay missing when backgroundedisNotLooping in the snapshot was sourced from Observable.shared.isNotLooping, which is only set by evaluateNotLooping() in MainViewController. Background BG refreshes built snapshots before that method ran, always producing isNotLooping = false. Fix: persist the last known loop time to Storage (lastLoopTime) whenever the pump clock is updated from device status. StorageCurrentGlucoseStateProvider now computes isNotLooping directly from this stored timestamp, so every refresh path (foreground, background, device status, audio recovery) produces the correct value.

  • APNs push token lost after renewal-overlay foreground restartwillEnterForegroundNotification and didBecomeActiveNotification always fire back-to-back. When the renewal overlay triggered handleForeground() to do an async end+restart, handleDidBecomeActive() fired a fraction later, found the old (dying) activity still in Activity.activities, bound to it, and started observePushToken() on it. The old activity's token had already been issued and wouldn't re-emit — so pushToken stayed nil. Fix: handleForeground() sets skipNextDidBecomeActive = true before starting the async restart, so handleDidBecomeActive() yields entirely.

SmallFamilyView improvements (CarPlay / Watch Smart Stack)

  • Right-hand slot is now configurable — any of the same LiveActivitySlotOption values available in the lock-screen grid. Defaults to Projected BG. Persisted via a new la.smallWidgetSlot App Group key.
  • Glucose-based slots (projectedBG, delta, minMax, target, isf) show the unit label using ViewThatFits(in: .vertical) — CarPlay shows the label, Watch omits it if space is tight. ISF correctly shows mmol/L/U / mg/dL/U rather than the bare glucose unit.
  • GlucoseSnapshot.Unit gains a displayName property ("mg/dL" / "mmol/L") as a single source of truth, replacing inline string literals.
  • Left VStack gets .layoutPriority(1) to prevent glucose/delta from being compressed by wide right-slot values.
  • Right slot value font reduced 24 pt → 20 pt.
  • Settings section headers: Grid Slots - Live Activity and Grid Slot - CarPlay / Watch.

Test plan

  • Disconnect loop; background app; confirm Not Looping overlay appears on lock screen LA
  • Let LA approach 8-hour renewal overlay; foreground; background again; confirm APNs updates resume (no manual Restart needed)
  • Set CarPlay/Watch slot to each option — verify value and unit label render correctly
  • Verify ISF slot shows mmol/L/U or mg/dL/U on CarPlay and bare value on Watch
  • Confirm left-side glucose/delta never get truncated regardless of right slot choice

🤖 Generated with Claude Code

MtlPhil and others added 30 commits March 19, 2026 20:39
  Registers com.loopfollow.audiorefresh with BGTaskScheduler so iOS
  can wake the app every ~15 min to check if the silent audio session
  is still alive and restart it if not.

  Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
  - Add 'fetch' to UIBackgroundModes so BGTaskScheduler.submit() doesn't
    throw notPermitted on every background transition
  - Call stopBackgroundTask() before startBackgroundTask() in the refresh
    handler to prevent accumulating duplicate AVAudioSession observers

  Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- startBackgroundTask() now removes the old observer before adding,
  making it idempotent and preventing duplicate interrupt callbacks
- Add 'audio restart initiated' log after restart so success is
  visible without debug mode
- Temporarily make 'Silent audio playing' log always visible for testing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- When renewIfNeeded fails in the background (app can't start a new LA
  because it's not visible), schedule a local notification on the first
  failure: "Live Activity Expiring — Open LoopFollow to restart."
  Subsequent failures in the same cycle are suppressed. Notification is
  cancelled if renewal later succeeds or forceRestart is called.
- In attachStateObserver, distinguish iOS force-dismiss (laRenewalFailed
  == true) from user swipe (laRenewalFailed == false). OS-dismissed LAs
  no longer set dismissedByUser, so opening the app triggers auto-restart
  as expected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Force-quitting an app kills its Live Activities, so cold-launch via
LA tap only occurs when iOS terminates the app — in which case
scene(_:openURLContexts:) already handles navigation correctly via
DispatchQueue.main.async. The flag was never set and never needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- BackgroundRefreshManager: all logs → .taskScheduler
- AppDelegate: APNs registration/notification logs → .apns
- APNSClient: all logs → .apns
- BackgroundTaskAudio: restore isDebug:true on silent audio log; fix double blank line
- LiveActivityManager: fix trailing whitespace; remove double blank line; SwiftFormat
- GlucoseSnapshotBuilder: fix file header (date → standard LoopFollow header)
- LoopFollowLiveActivity: remove dead commented-out activityID property
- SwiftFormat applied across all reviewed LiveActivity/, Storage/, extension files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prevents truncation toward zero (e.g. 179.9 → 179); now correctly rounds to nearest integer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BackgroundRefreshManager: guard against double setTaskCompleted if the
expiration handler fires while the main-queue block is in-flight. Apple
documents calling setTaskCompleted more than once as a programming error.

LiveActivityManager.renewIfNeeded: write laRenewBy to Storage only after
Activity.request succeeds, eliminating the narrow window where a crash
between the write and the request could leave the deadline permanently
stuck in the future. No rollback needed on failure. The fresh snapshot
is built via withRenewalOverlay(false) directly rather than re-running
the builder, since the caller already has a current snapshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Derive BGTask IDs, notification IDs, URL schemes, and notification
categories from Bundle.main.bundleIdentifier so that LoopFollow,
LoopFollow_Second, and LoopFollow_Third each get isolated identifiers
and don't interfere with each other's background tasks, notifications,
or Live Activities.

Also show the configured display name in the Live Activity footer
(next to the update time) when the existing "Show Display Name"
toggle is enabled, so users can identify which instance a LA belongs to.
Users upgrading from the old hardcoded identifiers would have orphaned
pending notifications that the new bundle-ID-scoped code can't cancel.
This one-time migration cleans them up on first launch.
The `bg` and `loopingResumed` refresh triggers fire ~10s apart. With a 5s
debounce, `loopingResumed` arrives after the debounce has already executed,
causing two APNs pushes per BG cycle instead of one. Widening the window to
20s ensures both events are coalesced into a single push containing the most
up-to-date post-loop-cycle state (fresh IOB, predicted BG, etc.).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When BGAppRefreshTask fires after a reboot (before the user has unlocked
the device), UserDefaults files are still encrypted (Before First Unlock
state). Reading migrationStep returns 0, causing all migrations to re-run.
migrateStep1 reads old_url from the also-locked App Group suite, gets "",
and writes "" to url — wiping Nightscout and other settings.

Fix: skip the entire migration block when the app is in background state.
Migrations will run correctly on the next foreground open. This is safe
since no migration is time-critical and all steps are guarded by version
checks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous fix used guard+return which skipped the entire viewDidLoad
when the app launched in background (BGAppRefreshTask). viewDidLoad only
runs once per VC lifecycle, so the UI was never initialized when the user
later foregrounded the app — causing a blank screen.

Fix: wrap only the migration block in an if-check, so UI setup always
runs. Migrations are still skipped in background to avoid BFU corruption.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MtlPhil and others added 28 commits March 22, 2026 22:24
…reground

During BFU viewDidLoad, all tasks fire with url="" and reschedule 60s out.
checkTasksNow() on first foreground finds nothing overdue. Fix: call
scheduleAllTasks() after reloadAll() so tasks reset to their normal 2-5s
initial delay, displacing the stale 60s BFU schedule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After BFU reloadAll(), viewDidLoad left isInitialLoad=false and no overlay.
Reset loading state and show the overlay so the user sees the same spinner
they see on a normal cold launch, rather than blank charts for 2-5 seconds.
The overlay auto-hides via the normal markLoaded() path when data arrives.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two-column layout: BG + trend arrow + delta/unit on the left (colored
by glucose threshold), projected BG + unit label on the right in white.
Dynamic Island and lock screen views are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
LoopFollowLiveActivityWidgetWithCarPlay is declared with
.supplementalActivityFamilies([.small]) so it is only ever rendered
in .small contexts (CarPlay, Watch Smart Stack). Use SmallFamilyView
directly instead of routing through LockScreenFamilyAdaptiveView,
which was falling through to LockScreenLiveActivityView when
activityFamily wasn't detected as .small.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two ActivityConfiguration widgets for the same attributes type were
registered simultaneously. The system used the primary widget for all
contexts, ignoring the supplemental one.

On iOS 18+: register only LoopFollowLiveActivityWidgetWithCarPlay
(with .supplementalActivityFamilies([.small]) and family-adaptive
routing via LockScreenFamilyAdaptiveView for all contexts).
On iOS <18: register only LoopFollowLiveActivityWidget (lock screen
and Dynamic Island only).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Revert bundle to if #available without else (WidgetBundleBuilder
  does not support if/else with #available)
- Make primary widget also use LockScreenFamilyAdaptiveView on iOS 18+
  so SmallFamilyView renders correctly regardless of which widget the
  system selects for .small contexts (CarPlay / Watch Smart Stack)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Scope all notification/task identifiers to Bundle.main.bundleIdentifier
  so multiple LoopFollow instances don't collide (BackgroundAlertManager,
  BackgroundRefreshManager, LiveActivityManager renewal notification)
- Derive URL scheme dynamically via AppGroupID.urlScheme
  (loopfollow → loopfollow2/3/etc. for additional instances)
- Update Info.plist: BGTask identifier and URL scheme use app_suffix var
- AppGroupID: extract baseBundleID computed var, add urlScheme computed var
- LAAppGroupSettings: add displayName/showDisplayName support for
  multi-instance LA footer (off by default, no behaviour change for single)
- Migration step 7: cancel legacy hardcoded notification identifiers
- LoopFollowLiveActivity.swift: apply urlScheme to all Link/widgetURL
  targets; add optional display name prefix to lock screen footer

Conflict on LoopFollowLAExtension/LoopFollowLiveActivity.swift resolved
by keeping our single-widget structure (SmallFamilyView / supplemental
family) and applying the URL scheme and display name changes from pr-branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The LiveActivity.md doc file should not be part of this PR.

https://claude.ai/code/session_01WaUhT8PoPNKumX9ZK9jeBy
The overlay was missing because isNotLooping in the snapshot was sourced
from Observable.shared.isNotLooping, which is only set by evaluateNotLooping()
in MainViewController. In background, BG refreshes could fire and build a
snapshot before evaluateNotLooping() had run, always producing isNotLooping=false.

Fix: persist the last known loop time to Storage (lastLoopTime) whenever the
pump clock is updated from device status. StorageCurrentGlucoseStateProvider
now computes isNotLooping directly from this stored timestamp instead of
reading the Observable flag, so every refresh — BG, device status, or
audio failure — independently produces the correct value regardless of
whether the UI code path has executed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the renewal overlay (or renewal-failed flag) caused handleForeground()
to end the old Live Activity and start a fresh one, handleDidBecomeActive()
was racing with it: it called startFromCurrentState() before the old activity
was fully ended, found it in Activity.activities, bound to it, and started
observing its push token. The new activity created by handleForeground()'s
async end+restart then had no token observer, leaving pushToken nil — so
background APNs updates stopped until the user manually hit Restart LA.

Fix: handleForeground() sets skipNextDidBecomeActive = true before starting
the async restart. handleDidBecomeActive() checks and consumes the flag,
skipping its competing startFromCurrentState() call entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the right side of the small widget (CarPlay / Watch Smart Stack)
was hardcoded to show projected BG. Replace it with a user-configurable slot
that can be any of the same options available in the lock-screen grid.

Changes:
- LAAppGroupSettings: add smallWidgetSlot() / setSmallWidgetSlot() backed by
  a new App Group key (la.smallWidgetSlot), defaulting to .iob
- LoopFollowLiveActivity: extract slotFormattedValue(option:snapshot:) as a
  shared file-private function used by both SlotView and SmallFamilyView;
  update SmallFamilyView right side to show the configured slot's gridLabel
  and value (trailing-aligned); hide the right side entirely when slot = .none
- LiveActivitySettingsView: add "Small widget (CarPlay / Watch)" section with
  a Right slot picker

Unit handling confirmed correct — LAFormat.glucose/delta/projected all go
through formatGlucoseValue(unit:) which respects snapshot.unit. No hardcoding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Glucose-based slots (projectedBG, delta, minMax, target, isf) now show
mg/dL or mmol/L below the value in the SmallFamilyView right slot.

Added isGlucoseUnit property to LiveActivitySlotOption to identify which
slots carry a glucose measurement. SmallFamilyView reads unitLabel from
snapshot.unit (already unit-aware) and renders it at 11pt below the value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Glucose-based slots (projectedBG, delta, minMax, target, isf) now show
the unit label (mg/dL / mmol/L) only when vertical space allows — CarPlay
Dashboard has enough room, Watch Smart Stack drops it gracefully.

Non-glucose slots are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
'Grid slots' → 'Grid Slots - Live Activity'
'Small widget (CarPlay / Watch)' → 'Grid Slot - CarPlay / Watch'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Reduce right slot value font 24pt → 20pt to prevent crowding the left side
- Add .layoutPriority(1) to left VStack so glucose/delta are never compressed
- Fix ISF unit label: rightSlotUnitLabel() now returns "mmol/L/U" or "mg/dL/U"
  for .isf instead of the plain glucose unit; other glucose slots unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GlucoseSnapshot.Unit.displayName returns "mg/dL" or "mmol/L" — the
string was previously duplicated inline wherever needed. SmallFamilyView
now uses snapshot.unit.displayName directly, removing the local
glucoseUnitLabel/deltaUnitLabel vars. The rightSlotUnitLabel helper
is simplified to a single guard + one ISF-specific branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bjorkert added a commit that referenced this pull request Mar 25, 2026
- Make SmallFamilyView right slot configurable via Live Activity settings
- Add Unit.displayName to GlucoseSnapshot for consistent unit labelling
- Use ViewThatFits for adaptive CarPlay vs Watch Smart Stack layout
- Fix APNs push token lost after renewal-overlay foreground restart
- Fix Not Looping overlay not showing when app is backgrounded
- Rename Live Activity settings section headers
@bjorkert bjorkert closed this Mar 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants